Add a "Dry Run" feature to the Agent form.

User can test if an agent being saved will work as expected simply by
clicking the "Dry Run" button, which opens up a modal window that shows
dry-run results including log, events and memory. Resolves #593.

The current implementation only allows dry-run of Agents that do not
require an incoming event to be run.

Also, each Agent class must opt in for this feature by declaring
themselves as `can_dry_run!`. To begin with, only WebsiteAgent is
marked as dry-runnable for now.

Akinori MUSHA 9 years ago
parent
commit
979f803759

+ 50 - 0
app/assets/javascripts/pages/agent-edit-page.js.coffee

@@ -8,6 +8,9 @@ class @AgentEditPage
8 8
       $("#agent_type").on "change", => @handleTypeChange(false)
9 9
       @handleTypeChange(true)
10 10
 
11
+    unless $('agent-dry-run-button').prop('disabled')
12
+      @enableDryRunButton()
13
+
11 14
   handleTypeChange: (firstTime) ->
12 15
     $(".event-descriptions").html("").hide()
13 16
     type = $('#agent_type').val()
@@ -50,6 +53,11 @@ class @AgentEditPage
50 53
           $('.agent-options').html(json.form_options) if json.form_options?
51 54
           window.jsonEditor = setupJsonEditor()[0]
52 55
 
56
+        if json.can_dry_run
57
+          @enableDryRunButton()
58
+        else
59
+          @disableDryRunButton()
60
+
53 61
         window.initializeFormCompletable()
54 62
 
55 63
         $("#agent-spinner").stop(true, true).fadeOut();
@@ -122,5 +130,47 @@ class @AgentEditPage
122 130
       else
123 131
         @hideEventCreation()
124 132
 
133
+  enableDryRunButton: ->
134
+    $(".agent-dry-run-button").prop('disabled', false).off().on "click", @invokeDryRun
135
+
136
+  disableDryRunButton: ->
137
+    $(".agent-dry-run-button").prop('disabled', true)
138
+
139
+  invokeDryRun: (e) ->
140
+    e.preventDefault()
141
+    $(".agent-dry-run-button").prop('disabled', true)
142
+    $('body').css(cursor: 'progress')
143
+    $.ajax type: 'POST', url: $(".agent-dry-run-button").data('action-url'), dataType: 'json', data: $(@form).serialize()
144
+      .done (json) =>
145
+        $("body").css(cursor: 'auto').append """
146
+          <div class="modal fade" tabindex="-1" id='dynamic-modal' role="dialog" aria-labelledby="dynamic-modal-label" aria-hidden="true">
147
+            <div class="modal-dialog modal-lg">
148
+              <div class="modal-content">
149
+                <div class="modal-header">
150
+                  <button type="button" class="close" data-dismiss="modal"><span aria-hidden="true">&times;</span><span class="sr-only">Close</span></button>
151
+                  <h4 class="modal-title" id="dynamic-modal-label"></h4>
152
+                </div>
153
+                <div class="modal-body">
154
+                  <h5>Log</h5>
155
+                  <pre class="agent-dry-run-log"></pre>
156
+                  <h5>Events</h5>
157
+                  <pre class="agent-dry-run-events"></pre>
158
+                  <h5>Memory</h5>
159
+                  <pre class="agent-dry-run-memory"></pre>
160
+                </div>
161
+              </div>
162
+            </div>
163
+          </div>
164
+        """
165
+        $('#dynamic-modal').find('.modal-title').text "Dry Run Results"
166
+        $('#dynamic-modal').find('.modal-body').
167
+          find('.agent-dry-run-log').text(json.log).end().
168
+          find('.agent-dry-run-events').text(json.events).end().
169
+          find('.agent-dry-run-memory').text(json.memory)
170
+        $('#dynamic-modal').modal('show').on 'hidden.bs.modal', ->
171
+          $('#dynamic-modal').remove()
172
+          $(".agent-dry-run-button").prop('disabled', false)
173
+        $(".agent-dry-run-button").prop('disabled', false)
174
+
125 175
 $ ->
126 176
   Utils.registerPage(AgentEditPage, forPathsMatching: /^agents/)

+ 62 - 0
app/concerns/dry_runnable.rb

@@ -0,0 +1,62 @@
1
+module DryRunnable
2
+  def dry_run!
3
+    class << self
4
+      prepend Sandbox
5
+    end
6
+
7
+    log = StringIO.new
8
+    @dry_run_logger = Logger.new(log)
9
+    @dry_run_results = {
10
+      events: [],
11
+    }
12
+
13
+    begin
14
+      raise "#{short_type} does not support dry-run" unless can_dry_run?
15
+      check
16
+    rescue => e
17
+      error "Exception during dry-run. #{e.message}: #{e.backtrace.join("\n")}"
18
+    end
19
+
20
+    @dry_run_results.update(
21
+      memory: memory,
22
+      log: log.string,
23
+    )
24
+  end
25
+
26
+  module Sandbox
27
+    attr_accessor :results
28
+
29
+    def logger
30
+      @dry_run_logger
31
+    end
32
+
33
+    def save
34
+      valid?
35
+    end
36
+
37
+    def save!
38
+      save or raise ActiveRecord::RecordNotSaved
39
+    end
40
+
41
+    def log(message, options = {})
42
+      case options[:level] || 3
43
+      when 0..2
44
+        sev = Logger::DEBUG
45
+      when 3
46
+        sev = Logger::INFO
47
+      else
48
+        sev = Logger::ERROR
49
+      end
50
+
51
+      logger.log(sev, message)
52
+    end
53
+
54
+    def create_event(event_hash)
55
+      if can_create_events?
56
+        @dry_run_results[:events] << event_hash[:payload]
57
+      else
58
+        error "This Agent cannot create events!"
59
+      end
60
+    end
61
+  end
62
+end

+ 31 - 10
app/controllers/agents_controller.rb

@@ -33,20 +33,41 @@ class AgentsController < ApplicationController
33 33
     end
34 34
   end
35 35
 
36
+  def dry_run
37
+    attrs = params[:agent]
38
+    if agent = current_user.agents.find_by(id: params[:id])
39
+      # PUT /agents/:id/dry_run
40
+      type = agent.type
41
+    else
42
+      # POST /agents/dry_run
43
+      type = attrs.delete(:type)
44
+    end
45
+    agent = Agent.build_for_type(type, current_user, attrs)
46
+    agent.name ||= '(Untitled)'
47
+    results = agent.dry_run!
48
+
49
+    render json: {
50
+        log: results[:log],
51
+        events: Utils.pretty_print(results[:events], false),
52
+        memory: Utils.pretty_print(results[:memory] || {}, false),
53
+    }
54
+  end
55
+
36 56
   def type_details
37 57
     @agent = Agent.build_for_type(params[:type], current_user, {})
38 58
     initialize_presenter
39 59
 
40
-    render :json => {
41
-        :can_be_scheduled => @agent.can_be_scheduled?,
42
-        :default_schedule => @agent.default_schedule,
43
-        :can_receive_events => @agent.can_receive_events?,
44
-        :can_create_events => @agent.can_create_events?,
45
-        :can_control_other_agents => @agent.can_control_other_agents?,
46
-        :options => @agent.default_options,
47
-        :description_html => @agent.html_description,
48
-        :oauthable => render_to_string(partial: 'oauth_dropdown', locals: { agent: @agent }),
49
-        :form_options => render_to_string(partial: 'options', locals: { agent: @agent })
60
+    render json: {
61
+        can_be_scheduled: @agent.can_be_scheduled?,
62
+        default_schedule: @agent.default_schedule,
63
+        can_receive_events: @agent.can_receive_events?,
64
+        can_create_events: @agent.can_create_events?,
65
+        can_control_other_agents: @agent.can_control_other_agents?,
66
+        can_dry_run: @agent.can_dry_run?,
67
+        options: @agent.default_options,
68
+        description_html: @agent.html_description,
69
+        oauthable: render_to_string(partial: 'oauth_dropdown', locals: { agent: @agent }),
70
+        form_options: render_to_string(partial: 'options', locals: { agent: @agent })
50 71
     }
51 72
   end
52 73
 

+ 13 - 0
app/models/agent.rb

@@ -12,6 +12,7 @@ class Agent < ActiveRecord::Base
12 12
   include LiquidInterpolatable
13 13
   include HasGuid
14 14
   include LiquidDroppable
15
+  include DryRunnable
15 16
 
16 17
   markdown_class_attributes :description, :event_description
17 18
 
@@ -194,6 +195,10 @@ class Agent < ActiveRecord::Base
194 195
     self.class.can_control_other_agents?
195 196
   end
196 197
 
198
+  def can_dry_run?
199
+    self.class.can_dry_run?
200
+  end
201
+
197 202
   def log(message, options = {})
198 203
     AgentLog.log_for_agent(self, message, options)
199 204
   end
@@ -328,6 +333,14 @@ class Agent < ActiveRecord::Base
328 333
       include? AgentControllerConcern
329 334
     end
330 335
 
336
+    def can_dry_run!
337
+      @can_dry_run = true
338
+    end
339
+
340
+    def can_dry_run?
341
+      !!@can_dry_run
342
+    end
343
+
331 344
     def gem_dependency_check
332 345
       @gem_dependencies_checked = true
333 346
       @gem_dependencies_met = yield

+ 2 - 0
app/models/agents/website_agent.rb

@@ -5,6 +5,8 @@ module Agents
5 5
   class WebsiteAgent < Agent
6 6
     include WebRequestConcern
7 7
 
8
+    can_dry_run!
9
+
8 10
     default_schedule "every_12h"
9 11
 
10 12
     UNIQUENESS_LOOK_BACK = 200

+ 2 - 1
app/views/agents/_options.erb

@@ -24,4 +24,5 @@
24 24
 <% end %>
25 25
 <div class="form-group">
26 26
   <%= submit_tag "Save", :class => "btn btn-primary" %>
27
-</div>
27
+  <%= button_tag class: 'btn btn-default agent-dry-run-button', type: 'button', disabled: !agent.can_dry_run?, 'data-action-url' => agent.persisted? ? dry_run_agent_path(agent) : dry_run_agents_path do %><%= icon_tag('glyphicon-refresh') %> Dry Run<% end %>
28
+</div>

+ 2 - 0
config/routes.rb

@@ -2,6 +2,7 @@ Huginn::Application.routes.draw do
2 2
   resources :agents do
3 3
     member do
4 4
       post :run
5
+      put :dry_run
5 6
       post :handle_details_post
6 7
       put :leave_scenario
7 8
       delete :remove_events
@@ -10,6 +11,7 @@ Huginn::Application.routes.draw do
10 11
     collection do
11 12
       post :propagate
12 13
       get :type_details
14
+      post :dry_run
13 15
       get :event_descriptions
14 16
       post :validate
15 17
       post :complete

+ 56 - 0
spec/concerns/dry_runnable_spec.rb

@@ -0,0 +1,56 @@
1
+require 'spec_helper'
2
+
3
+describe DryRunnable do
4
+  class Agents::SandboxedAgent < Agent
5
+    default_schedule "3pm"
6
+
7
+    can_dry_run!
8
+
9
+    def check
10
+      log "Logging"
11
+      create_event payload: { test: "foo" }
12
+      error "Recording error"
13
+      create_event payload: { test: "bar" }
14
+      self.memory = { last_status: "ok" }
15
+      save!
16
+    end
17
+  end
18
+
19
+  before do
20
+    stub(Agents::SandboxedAgent).valid_type?("Agents::SandboxedAgent") { true }
21
+
22
+    @agent = Agents::SandboxedAgent.create(name: "some agent") { |agent|
23
+      agent.user = users(:bob)
24
+    }
25
+  end
26
+
27
+  it "traps logging, event emission and memory updating" do
28
+    results = nil
29
+
30
+    expect {
31
+      results = @agent.dry_run!
32
+    }.not_to change {
33
+      [users(:bob).agents.count, users(:bob).events.count, users(:bob).logs.count]
34
+    }
35
+
36
+    expect(results[:log]).to match(/\AI, .+ INFO -- : Logging\nE, .+ ERROR -- : Recording error\n/)
37
+    expect(results[:events]).to eq([{ test: 'foo' }, { test: 'bar' }])
38
+    expect(results[:memory]).to eq({ "last_status" => "ok" })
39
+  end
40
+
41
+  it "does not perform dry-run if Agent does not support dry-run" do
42
+    stub(@agent).can_dry_run? { false }
43
+
44
+    results = nil
45
+
46
+    expect {
47
+      results = @agent.dry_run!
48
+    }.not_to change {
49
+      [users(:bob).agents.count, users(:bob).events.count, users(:bob).logs.count]
50
+    }
51
+
52
+    expect(results[:log]).to match(/\AE, .+ ERROR -- : Exception during dry-run. SandboxedAgent does not support dry-run: /)
53
+    expect(results[:events]).to eq([])
54
+    expect(results[:memory]).to eq({})
55
+  end
56
+end

+ 27 - 0
spec/controllers/agents_controller_spec.rb

@@ -347,4 +347,31 @@ describe AgentsController do
347 347
       end
348 348
     end
349 349
   end
350
+
351
+  describe "POST dry_run" do
352
+    it "does not actually create any agent, event or log" do
353
+      sign_in users(:bob)
354
+      expect {
355
+        post :dry_run, agent: valid_attributes()
356
+      }.not_to change {
357
+        [users(:bob).agents.count, users(:bob).events.count, users(:bob).logs.count]
358
+      }
359
+      json = JSON.parse(response.body)
360
+      expect(json['log']).to be_a(String)
361
+      expect(json['events']).to be_a(String)
362
+      expect(JSON.parse(json['events']).map(&:class)).to eq([Hash])
363
+      expect(json['memory']).to be_a(String)
364
+      expect(JSON.parse(json['memory'])).to be_a(Hash)
365
+    end
366
+
367
+    it "does not actually update an agent" do
368
+      sign_in users(:bob)
369
+      agent = agents(:bob_weather_agent)
370
+      expect {
371
+        post :dry_run, id: agents(:bob_website_agent), agent: valid_attributes(name: 'New Name')
372
+      }.not_to change {
373
+        [users(:bob).agents.count, users(:bob).events.count, users(:bob).logs.count, agent.name, agent.updated_at]
374
+      }
375
+    end
376
+  end
350 377
 end